--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit fae620ebc2748d25a93fdc16a1b159619d71025c
Parents : c9b78a0
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T16:10:09-05:00
feat(tests): add regression tests for Android BLE permissions and enhance CSP checks in frontend
Changes
4 files changed, 215 insertions(+), 0 deletions(-)
Diff
diff --git a/tests/backend/test_android_ble_permissions.py b/tests/backend/test_android_ble_permissions.py
new file mode 100644
index 00000000..db489ad0
--- /dev/null
+++ b/tests/backend/test_android_ble_permissions.py
@@ -0,0 +1,40 @@
+# SPDX-License-Identifier: 0BSD
+"""Regression guards for Android BLE permission lint and type stubs."""
+
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+BLE_JAVA = REPO_ROOT / "android/app/src/main/java/org/able/BLE.java"
+TYPINGS = REPO_ROOT / "typings"
+
+
+def test_ble_java_checks_bluetooth_permissions_before_privileged_calls():
+ source = BLE_JAVA.read_text(encoding="utf-8")
+ assert '@SuppressLint("MissingPermission")' in source
+ assert "hasScanPermission" in source
+ assert "hasConnectPermission" in source
+ assert "Manifest.permission.BLUETOOTH_SCAN" in source
+ assert "Manifest.permission.BLUETOOTH_CONNECT" in source
+ assert "ContextCompat.checkSelfPermission" in source
+ assert "SecurityException" in source
+ for method in (
+ "startScan(",
+ "stopScan(",
+ "connectGatt(",
+ "writeCharacteristic(",
+ "readCharacteristic(",
+ "readRemoteRssi(",
+ ):
+ assert method in source
+
+
+def test_android_optional_import_typings_exist_for_basedpyright():
+ required = (
+ TYPINGS / "java" / "__init__.pyi",
+ TYPINGS / "usb4a" / "__init__.pyi",
+ TYPINGS / "usb4a" / "usb.pyi",
+ TYPINGS / "able" / "__init__.pyi",
+ TYPINGS / "jnius" / "__init__.pyi",
+ )
+ missing = [str(path.relative_to(REPO_ROOT)) for path in required if not path.is_file()]
+ assert missing == []
diff --git a/tests/backend/test_csp_logic.py b/tests/backend/test_csp_logic.py
index 55eac4a5..5654c7ac 100644
--- a/tests/backend/test_csp_logic.py
+++ b/tests/backend/test_csp_logic.py
@@ -70,6 +70,9 @@ async def test_csp_header_logic(mock_rns_minimal, tmp_path):
assert "wasm-unsafe-eval" in csp
m = re.search(r"script-src([^;]+);", csp)
assert m is not None and "blob:" in m.group(1)
+ script_src = m.group(1)
+ assert "'unsafe-inline'" not in script_src
+ assert "'self'" in script_src
@pytest.mark.asyncio
@@ -221,3 +224,62 @@ async def test_csp_privacy_mode_strips_external_sources(mock_rns_minimal, tmp_pa
assert "api.example.com" not in csp
assert "tiles.example.com" not in csp
assert "connect-src 'self'" in csp
+
+
+def _script_src_directive(csp: str) -> str:
+ m = re.search(r"script-src([^;]+);", csp)
+ assert m is not None
+ return m.group(1)
+
+
+async def _csp_for_path(app_instance, path: str) -> str:
+ request = MagicMock(spec=web.Request)
+ request.path = path
+ request.app = {}
+
+ async def mock_handler(req):
+ return web.Response(text="test")
+
+ routes = web.RouteTableDef()
+ _, _, security_middleware, _, _ = app_instance._define_routes(routes)
+ response = await security_middleware(request, mock_handler)
+ return response.headers.get("Content-Security-Policy", "")
+
+
+@pytest.mark.asyncio
+async def test_main_app_csp_rejects_inline_scripts(mock_rns_minimal, tmp_path):
+ """Main UI CSP must not allow unsafe-inline scripts (boot theme is external)."""
+ storage_dir = str(tmp_path / "storage")
+ config_dir = str(tmp_path / "config")
+
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app_instance = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=storage_dir,
+ reticulum_config_dir=config_dir,
+ )
+ for path in ("/", "/index.html", "/boot-theme.js"):
+ csp = await _csp_for_path(app_instance, path)
+ script_src = _script_src_directive(csp)
+ assert "'unsafe-inline'" not in script_src, path
+ assert "'unsafe-eval'" not in script_src, path
+ assert "'self'" in script_src
+ assert "blob:" in script_src
+ assert "wasm-unsafe-eval" in script_src
+
+
+@pytest.mark.asyncio
+async def test_rnode_flasher_csp_allows_inline_and_eval(mock_rns_minimal, tmp_path):
+ storage_dir = str(tmp_path / "storage")
+ config_dir = str(tmp_path / "config")
+
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app_instance = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=storage_dir,
+ reticulum_config_dir=config_dir,
+ )
+ csp = await _csp_for_path(app_instance, "/rnode-flasher/index.html")
+ script_src = _script_src_directive(csp)
+ assert "'unsafe-inline'" in script_src
+ assert "'unsafe-eval'" in script_src
diff --git a/tests/frontend/CspBootTheme.test.js b/tests/frontend/CspBootTheme.test.js
new file mode 100644
index 00000000..597d0a69
--- /dev/null
+++ b/tests/frontend/CspBootTheme.test.js
@@ -0,0 +1,91 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { existsSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const ROOT = resolve(import.meta.dirname, "../..");
+const INDEX_HTML = resolve(ROOT, "meshchatx/src/frontend/index.html");
+const BOOT_THEME_JS = resolve(ROOT, "meshchatx/src/frontend/public/boot-theme.js");
+const PUBLIC_DIR = resolve(ROOT, "meshchatx/src/frontend/public");
+
+const INLINE_SCRIPT_RE = /<script(?![^>]*\bsrc\s*=)[^>]*>/gi;
+const SCRIPT_SRC_RE = /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
+
+describe("main app CSP and boot theme", () => {
+ beforeEach(() => {
+ document.documentElement.className = "";
+ delete document.documentElement.dataset.bootTheme;
+ document.documentElement.style.colorScheme = "";
+ window.localStorage.clear();
+ delete window.MeshChatXAndroid;
+ });
+
+ afterEach(() => {
+ document.documentElement.className = "";
+ delete document.documentElement.dataset.bootTheme;
+ document.documentElement.style.colorScheme = "";
+ window.localStorage.clear();
+ delete window.MeshChatXAndroid;
+ });
+
+ it("index.html has no inline scripts (main CSP forbids unsafe-inline)", () => {
+ const html = readFileSync(INDEX_HTML, "utf8");
+ const inline = [...html.matchAll(INLINE_SCRIPT_RE)].map((m) => m[0]);
+ expect(inline).toEqual([]);
+ });
+
+ it("index.html loads boot-theme.js from public before the app module", () => {
+ const html = readFileSync(INDEX_HTML, "utf8");
+ expect(existsSync(BOOT_THEME_JS)).toBe(true);
+ expect(html).toContain('src="/boot-theme.js"');
+ const bootIdx = html.indexOf('src="/boot-theme.js"');
+ const mainIdx = html.indexOf('src="main.js"');
+ expect(bootIdx).toBeGreaterThan(-1);
+ expect(mainIdx).toBeGreaterThan(bootIdx);
+ });
+
+ it("every classic script src in index.html resolves under public or is the vite entry", () => {
+ const html = readFileSync(INDEX_HTML, "utf8");
+ const srcs = [...html.matchAll(SCRIPT_SRC_RE)].map((m) => m[1]);
+ expect(srcs.length).toBeGreaterThan(0);
+ for (const src of srcs) {
+ if (src === "main.js" || src.endsWith("/main.js")) {
+ continue;
+ }
+ const rel = src.replace(/^\//, "");
+ expect(existsSync(resolve(PUBLIC_DIR, rel)), `missing public asset for ${src}`).toBe(
+ true,
+ );
+ }
+ });
+
+ it("boot-theme.js applies dark theme by default", () => {
+ const code = readFileSync(BOOT_THEME_JS, "utf8");
+ // eslint-disable-next-line no-new-func
+ Function(code)();
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+ expect(document.documentElement.dataset.bootTheme).toBe("dark");
+ expect(document.documentElement.style.colorScheme).toBe("dark");
+ });
+
+ it("boot-theme.js respects localStorage light theme", () => {
+ window.localStorage.setItem("meshchatx_ui_theme", "light");
+ const code = readFileSync(BOOT_THEME_JS, "utf8");
+ // eslint-disable-next-line no-new-func
+ Function(code)();
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+ expect(document.documentElement.dataset.bootTheme).toBe("light");
+ expect(document.documentElement.style.colorScheme).toBe("light");
+ });
+
+ it("boot-theme.js prefers Android bridge theme over localStorage", () => {
+ window.localStorage.setItem("meshchatx_ui_theme", "dark");
+ window.MeshChatXAndroid = {
+ getPreferredUiTheme: () => "light",
+ };
+ const code = readFileSync(BOOT_THEME_JS, "utf8");
+ // eslint-disable-next-line no-new-func
+ Function(code)();
+ expect(document.documentElement.dataset.bootTheme).toBe("light");
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+ });
+});
diff --git a/tests/frontend/i18n.test.js b/tests/frontend/i18n.test.js
index 7515fbe5..f6237450 100644
--- a/tests/frontend/i18n.test.js
+++ b/tests/frontend/i18n.test.js
@@ -112,4 +112,26 @@ describe("i18n Localization Tests", () => {
// We expect some false positives if keys are constructed dynamically.
expect(nonDynamicMissing.length).toBe(0);
});
+
+ it("keeps docs upload/share keys present in every locale", () => {
+ const required = [
+ "docs.btn_share",
+ "docs.error",
+ "docs.failed_upload_docs",
+ "docs.upload_success",
+ "common.prompt_title",
+ ];
+ for (const key of required) {
+ const parts = key.split(".");
+ for (const [code, data] of Object.entries(allLocales)) {
+ let current = data;
+ for (const part of parts) {
+ expect(current?.[part], `${code} missing ${key}`).toBeDefined();
+ current = current[part];
+ }
+ expect(typeof current).toBe("string");
+ expect(current.length).toBeGreaterThan(0);
+ }
+ }
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────